Design - Theming

September 26, 2026

 

This document describes ThemeManager. ThemeManager applies colors to every MoreForm, MoreUserControl, and More* control.

 

This document covers three topics:

  • How ThemeManager selects a theme.
  • How ThemeManager applies a theme to a dialog.
  • How individual controls join the theming system.

 

This document does not cover OneNote's page-color remapping. This document does not cover raw Office theme registry values. See TechNote - Colors for that information.

 

Why ThemeManager Exists

 

OneMore draws its own dialogs on top of the Office ribbon. The Office ribbon has its own theme setting (Colorful, Dark Gray, Black, White, or System). This setting can be scraped from the Registry by the OneMore process but OneMore lets users choose the appearance of its own dialogs independently.

 

Architecture

OneMore Theming — Simplified Architecture (Extract)

 

ThemeManager is a singleton

 

ThemeManager loads one time. The first code that touches Instance triggers this load. ThemeManager then caches the color table for the life of the process.

 

public static ThemeManager Instance => instance ??= new ThemeManager();

 

MoreForm and MoreUserControl each cache this instance in a protected field. Individual More* controls do the same. OneMore does not use a dependency-injection container for this.

 

ThemeManager stores the palette directly

 

ThemeManager holds three members:

  • Dictionary<string, Color> Colors — a flat color table.
  • bool DarkMode { get; private set; } — the current mode flag.
  • GetColor(string key) — a method that returns one named color.

 

There is no separate Theme class. ThemeManager is the palette.

 

How ThemeManager Selects a Theme

 

The three available modes

 

The product UI exposes three modes. GeneralSheet's theme dropdown sets one of these three:

 

  1. System — Follow the Windows or Office setting.
  2. Light — Always use the light palette.
  3. Dark — Always use the dark palette.

 

A fourth mode, User, exists in the code. No settings-sheet option sets this mode. See "Custom Themes" below for the actual mechanism.

 

Selection order

 

LoadColors(int modeIndex = -1) runs the following steps in order. Step 1 always wins when its condition is true.

 

  1. Check for a custom theme file at PathHelper.GetAppDataPath()\OneMoreTheme.json. If this file exists, ThemeManager deserializes it and skips every step below. This file wins regardless of the current ThemeMode setting.
  2. If no custom file exists, determine the mode. Use the modeIndex argument when the caller supplies a value of 0 or greater. Otherwise read the persisted Theme setting from SettingsProvider.
  3. Compute the DarkMode flag from the mode:
    • DarkMode is always false at design time.
    • Otherwise, DarkMode is true when the mode is Dark.
    • DarkMode is also true when the mode is System and Office reports its own Black theme (checked through Office.IsBlackThemeEnabled(true)).
    • When Office itself is set to "System," this check falls through to the Windows registry key AppsUseLightTheme.
    • This check always ignores OneNote's own per-page "Switch Background" override. Dialog theming tracks the Office chrome theme only.
  4. Load the color table from one embedded JSON resource: DarkTheme.json for dark mode, or LightTheme.json for light mode. A custom JsonConverter<Color> reads each entry. Each entry is either a #RRGGBB hex string or a known System.Drawing color name.

 

Why design time is excluded

 

IsDesignTime gates every color assignment in this system. Visual Studio's Designer.cs generator does not honor ShouldSerializeXxx conventions for Control.BackColor and Control.ForeColor overrides in .NET Framework. A plain property assignment at design time therefore becomes a hardcoded value in the generated Designer.cs file. Skipping the assignment at design time prevents this permanent, wrong-for-runtime value from appearing the next time a developer opens the form in the designer.

 

Applying a Theme to a Form

 

MoreForm.OnLoad calls manager.InitializeTheme(this).

 

This call triggers two separate tree walks.

 

Walk 1: Colorize(Control control)

 

This walk assigns BackColor and ForeColor directly. The walk visits the parent control first, then recurses into control.Controls.

 

For most controls, Colorize maps the design-time placeholder color to its themed equivalent. When a control implements IThemedControl, Colorize calls control.ApplyTheme(this) instead of doing the default assignment. Colorize also contains special-case branches for six control types: ComboBox, Label, PictureBox, ListView items, StatusStrip items, and DateTimePicker.

 

Colorize skips two categories of control entirely. Colorize skips ListView. Colorize skips every ToolStrip and MenuStrip except StatusStrip. Both categories manage their own coloring, described below.

 

Walk 2: LoadControls(Control.ControlCollection controls)

 

This walk is a local recursive function inside MoreForm.OnLoad. The walk calls ((ILoadControl)child).OnLoad() on every descendant that implements ILoadControl. The walk visits parents before children. The walk applies no other type filter.

 

Simple controls without another load hook use this interface for one-time themed setup. Examples include resolving colors and swapping an icon for its dark-mode variant.

 

Which walks run on which container

 

Only MoreForm.OnLoad runs the ILoadControl walk. MoreUserControl.OnLoad calls manager.InitializeTheme(this) for its own Colorize pass, but it does not walk its own ILoadControl children.

 

Therefore, a MoreUserControl hosted inside a MoreForm gets its ILoadControl descendants themed by the host form's single top-level walk. The user control does not theme these descendants independently.

 

SheetBase, the settings-sheet base class, implements ILoadControl for this reason. This lets a settings sheet hosted inside a container dialog participate correctly in that dialog's outer walk.

 

The Two Control-Side Contracts

 

IThemedControl

 

A control implements IThemedControl when it must resolve its own colors, optionally against per-instance overrides.

 

internal interface IThemedControl

{

    string ThemedBack { get; set; } // e.g. "ErrorText" for a validation field

    string ThemedFore { get; set; }

    void ApplyTheme(ThemeManager manager);

}

 

Colorize calls ApplyTheme(this) on any control that implements this interface. Colorize does not perform its own default color assignment on that control.

 

ILoadControl

 

A control implements ILoadControl when it needs one-time load logic and has no other extensibility point. Button and Label are two examples.

 

internal interface ILoadControl

{

    Control.ControlCollection Controls { get; }

    void OnLoad(); // declared as: void ILoadControl.OnLoad() { }

}

 

A control can implement both interfaces

 

The two interfaces are not mutually exclusive. A control can route its real logic through one interface. That same control can leave the other interface as a near no-op stub, solely to satisfy Colorize's dispatch check.

 

MoreDataGridView implements both interfaces this way. ApplyTheme does nothing in MoreDataGridView. All real theming logic in MoreDataGridView runs inside ILoadControl.OnLoad().

 

How Individual More* Controls Theme Themselves

 

No single required pattern exists. Each control uses whichever mechanism its underlying WinForms base class needs.

 

MoreButton uses ILoadControl only. This control is fully owner-drawn (ControlStyles.UserPaint). OnLoad() resolves BackColor and ForeColor, respecting ThemedBack and ThemedFore. When StylizeImage is set and manager.DarkMode is true, OnLoad() runs the button's image through an inverting ImageEditor. OnPaint selects background and border colors directly from the manager, based on hover, pressed, and focus state.

 

MoreTextBox uses ILoadControl only. OnLoad() sets ForeColor and BackColor from ThemedFore and ThemedBack. When neither value is set, OnLoad() falls back to "WindowText" and "Window". OnEnabledChanged re-runs this same logic. This lets toggling Enabled re-theme the control live, producing a grayed background and text, without a full reload.

 

MoreListView implements neither interface. Colorize explicitly skips this control. MoreListView is fully owner-drawn (OwnerDraw = true). It pulls colors directly from manager.GetColor(...) inside its DrawColumnHeader, DrawItem, and DrawSubItem handlers. Two configurable properties, SelectedBackColorKey and SelectedForeColorKey, default to "Highlight" and "HighlightText". MoreListView also sends the native LVM_SETBKCOLOR message on handle creation. This message is necessary because WinForms ListView does not forward BackColor to the blank area below the last row.

 

MoreDataGridView implements both interfaces. ApplyTheme is a deliberate no-op. ILoadControl.OnLoad() performs the real work: setting BackgroundColor, ForeColor, and GridColor from "WindowFrame", and setting header, row, and cell DefaultCellStyle colors. OnLoad() also sets EnableHeadersVisualStyles = false.

 

MoreMenuStrip and MoreToolStrip use ILoadControl only. Colorize also skips both controls, because Colorize skips every ToolStrip and MenuStrip except StatusStrip. Instead, each control supplies a custom ToolStripProfessionalRenderer at construction. This renderer is built on ThemedColorTable (OneMore/UI/ThemedColorTable.cs), a ProfessionalColorTable override. ThemedColorTable pulls five named colors from ThemeManager.Instance: "MenuBar", "MenuHighlight", "MenuMargin", "MenuSeparator", and "WindowFrame". Three related controls — MoreMenuItem, MoreToolStripButton, and MoreSplitButton — override their Image setter. This override auto-inverts each icon through ImageEditor whenever manager.DarkMode is true. This is the general pattern OneMore uses for icon-per-theme support, without maintaining a separate dark asset for most toolbar and menu glyphs.

 

OnThemeChange()

 

public virtual void OnThemeChange() { } // MoreForm and MoreUserControl

 

ThemeManager.InitializeTheme(ContainerControl) calls OnThemeChange(), but only when DarkMode is true. A light-themed dialog never receives this call.

 

This method is a load-time hook, not a live-update hook. OnThemeChange() fires exactly one time, from OnLoad, before Colorize runs.

 

This hook exists for one specific case: a control that holds cached, expensive-to-recreate Brush, Pen, or Image state for owner-drawn rendering. A static color assignment cannot cover this case.

 

The codebase contains one real override: SearchResultsCardView.OnThemeChange. This override reallocates the view's cached SolidBrush and Pen objects from manager.GetColor(...), for its owner-drawn card view.

 

No Live Theme Switching

 

ThemeManager loads its color table one time. ThemeManager caches this table for the process lifetime. Three mechanisms are absent from the theming code:

  • A SystemEvents.UserPreferenceChanged subscription.
  • WM_SETTINGCHANGE handling.
  • A registry of currently open forms.

 

Therefore, a Windows or Office theme change has no effect while OneMore is running. This applies to already-open dialogs. This also applies to newly opened dialogs.

 

Two events end this state. First, the dllhost.exe COM surrogate process restarts. Second, the user changes the theme dropdown in GeneralSheet; this action calls ThemeManager.Instance.LoadColors(themeBox.SelectedIndex) directly.

 

A developer building a new dialog can treat "the theme" as a fixed input for that dialog's lifetime. No live-update case needs handling.

 

Beyond WinForms

 

Ribbon icons

 

AddinRibbon.GetRibbonImage(string imageName) binds to the ribbon XML's loadImage callback. This method checks Office.IsBlackThemeEnabled(true). When this check returns true, the method looks for a resource named $"Dark{imageName}" before it falls back to the plain imageName.

 

Ribbon icons differ from the toolstrip and menu pattern above. Ribbon icons use explicitly authored dark variants. Ribbon icons do not use runtime inversion. No single owner-drawn surface exists to hook into for the ribbon.

 

OneNote page content

 

ThemeManager does not touch OneNote page content. Page-color and ink-color remapping for the dark and light canvas is a separate concern. PageColorCommand, PageColors, and ColorExtensions handle this concern. See TechNote - Colors for documentation.

 

WebViewDialog

 

WebViewDialog performs no dark-mode CSS or JS injection. WebView2 content renders exactly as authored. ThemeManager has no involvement with WebViewDialog.

 

Custom Themes

 

No in-app editor exists for custom themes.

 

A user, or a future settings sheet, can drop a JSON file at PathHelper.GetAppDataPath()\OneMoreTheme.json. This file uses the same shape as DarkTheme.json and LightTheme.json: a DarkMode bool plus a Colors map.

 

LoadColors picks up this file unconditionally on its next run. ThemeManager.HasCustomTheme() performs a simple File.Exists check; other code can call this method to detect a custom theme.

 

Because ThemeManager caches its color table for the process lifetime, dropping this file does not retheme an already-running instance immediately. The new file takes effect after one of the two triggers described in "No Live Theme Switching" above: a dllhost.exe restart, or the user working the GeneralSheet theme dropdown.

 

References

 

Item

Contents

OneMore/UI/ThemeManager.cs

Singleton, ThemeMode, LoadColors, InitializeTheme, Colorize, HasCustomTheme

OneMore/UI/IThemedControl.cs, ILoadControl.cs

The two control-side contracts

OneMore/UI/DarkTheme.json, LightTheme.json

Built-in palettes

OneMore/UI/ThemedColorTable.cs

ProfessionalColorTable for menu and toolstrip renderers

OneMore/UI/MoreButton.cs, MoreTextBox.cs, MoreListView.cs, MoreDataGridView.cs, MoreMenuStrip.cs, MoreToolStrip.cs

Individual control implementations

OneMore/Helpers/Office/Office.cs

IsBlackThemeEnabled, SystemDefaultDarkMode, DarkModeLightsOn

OneMore/Commands/Settings/GeneralSheet.cs

The only in-app theme picker (System, Light, Dark)

OneMore/Ribbon/AddinRibbon.cs

GetRibbonImage, dark ribbon-icon fallback convention

TechNote - Colors

OneNote's own page and ink color remapping; Office theme registry values

Design - UI Layer

MoreForm and MoreUserControl; the modal and modeless mechanics this document builds on

 

Note

 

OneMoreCalendar, the companion tray app, has its own separate theming stack: ThemeProvider, ThemedForm, ThemedUserControl. This stack includes a working in-app theme editor. This code is structurally similar to ThemeManager. This code is independent from ThemeManager. This document does not cover OneMoreCalendar.

 

 

OneMore Theming — Simplified Architecture PlantUML (Refresh)

 

 

 

#omwiki #omdeveloper #omdesign

 

© 2021 Steven M Cohn. All rights reserved.

Please consider a sponsorship or one-time donation to support ongoing development

 

 

Created with OneNote.